Skip to content

使用 decimal 代替 float

使用 decimal 代替 float

Decimal 和 Float 或者 Double 类型一样,可以用来表示实数

相比于另外两种类型,Decimal 有下列优点:

  1. 它的计算方式和人类所学的计算方式一样
  2. 精确表达
  3. 在计算和比较中精度不会丢失
  4. Decimal在计算和输出的时候不会丢失尾随0
  5. 可以改变精度设置,而不是和 float 或者 double 一样,精度是和语言实现或者硬件相关

数据库使用 decimal

mysql

DDL 定义 DECIMAL,包含两部分精度(p, d)

p 代表支持的总的数值长度精度(1~65)

d 代表支持的小数点后精度(0~30)

考虑遵守公认会计原则(GAAP)规则,货币栏必须至少包含4位小数,我们在使用时可以设置p为12,d为4。

mongodb

MongoDB 3.4 新增对decimal128 format的支持,最多支持34位小数位。腾讯云的版本为3.2,所以目前我们在mongo这边不支持。

可以考虑使用字符串格式代替,计算的时候转化成decimal。

SqlAlchemy

SqlAlchemy使用Numeric这个类型来表示Decimal

>>> class SKU(Base):
>>>     __tablename__ = 'sku'
>>> 
>>>     id = Column(Integer, primary_key=True)
>>>     name = Column(String(32))
>>>     price = Column(Numeric(12, 4))

>>> Session.add(SKU(name='test1', price=Decimal('10.030')))
>>> Session.commit()
>>> sku = Session.query(SKU).first()
>>> sku.price
Decimal('10.0300')  # 可以看到这个输出有4位小数精度,和我们在 Scheme 内定义的一致

Python

我们在数据库内使用 Decimal 存储数据,那么为了不丢失精度,在代码中也需要在整个过程中使用 Decimal。

输入, param_check 应该加一个 decimal 字段的检查

输出,返回前端的数据将 decimal 转化成 str 输出

计算

Python 的 decimal模块主要由三部分构成:the decimal number ,the context of arithmetic ,signals 。

  1. decimal number是不可改变的常量,它也不会截取小数点后多余的0;除了正常的数外, 它还包括'Infinity','-Infinity','NaN'等数。
  2. the context of arithmetic是当前计算环境的一些参数,包括精度位数prec,舍弃位数规则rounding,指数的最大值最小值Emin、Emax,科学计数法e的大小写Capitals,指数是否超出范围clamped,运算结果的标志flags,哪些操作要触发traps等。
  3. signals是在运算过程中产生的一些状态,这些状态可以根据需要用来提示、忽略、报错等。signals 被触发会出现在 flags 内。

给一个例子简单说明一下context里面flags和trpas的作用

>>> getcontext()
Context(prec=28, rounding=ROUND_HALF_EVEN, Emin=-999999, Emax=999999, capitals=1, clamp=0, flags=[], traps=[InvalidOperation, DivisionByZero, Overflow])

>>> Decimal('1.3000')/0
---------------------------------------------------------------------------
DivisionByZero                            Traceback (most recent call last)
<ipython-input-23-13484ddb1fdf> in <module>()
----> 1 Decimal('1.3000')/0

DivisionByZero: [<class 'decimal.DivisionByZero'>]

>>> getcontext()
Context(prec=28, rounding=ROUND_HALF_EVEN, Emin=-999999, Emax=999999, capitals=1, clamp=0, flags=[DivisionByZero], traps=[InvalidOperation, DivisionByZero, Overflow])

>>> getcontext().traps[DivisionByZero]=False
>>> Decimal('1.3000')/0
Decimal('Infinity')  # 返回的结果是正无穷

>>> getcontext()
Context(prec=28, rounding=ROUND_HALF_EVEN, Emin=-999999, Emax=999999, capitals=1, clamp=0, flags=[DivisionByZero], traps=[InvalidOperation, Overflow])

可以看到traps负责控制哪些异常需要抛出,而flags则记录了计算中出现的异常

值得一提的是flags是不会在计算结束之后自动清空的,他会记录曾经出现过的所有异常,如果你需要监控调试计算,需要手动使用clear_flags清空flags

我们在使用 Decimal 的时候应该在工程全局设置 context 以保证所有地方的异常和边界计算方法都是一致的。当然有特殊需求也可以利用 with 语句在局部调整设置。

decimal模块中提供了10种signals,下面简单介绍一下:

1)Clamped:越界,指数超出Emin或Emax范围;如果发生,则会在小数部分添加0来表示;

2)DecimalException;异常基类,我们不会用到。

3)DivisionByZero:在除法运算中出现,除数为0;如果不捕捉该错误,则返回Infinity或-Infinity;

4)Inexact:不精确,使用round函数舍弃的小数部分中包含除0以外的数字;

5)InvalidOperation:无效计算或计算无意义,比如两个无穷大相减等;如果不捕捉该错误,则返回NaN(Not a Number);

6)Overflow:在round后指数超出Emax范围,如果不捕捉,则根据round规则来判断返回什么值;

7)Rounded:如果round操作舍弃了小数,不管是不是0,都发生;如果不捕捉,则返回 值未改变;

8)Subnormal:指数值过小;如果不捕捉,则返回 值不变;

9)Underflow:指数值太小,且round操作向0逼近;

10)FloatOperation:如果不捕捉,则混合float型和Decimal型的操作可以执行;如果捕捉,则只有相等判断和显式转换可以执行,其余的都报错。

关于舍入的方法,Decimal中有以下几种类型:

1)ROUND_UP:舍弃小数部分非0时,在前面增加数字,如 5.21 -> 5.3;

2)ROUND_DOWN:舍弃小数部分,从不在前面数字做增加操作,如5.21->5.2;

3)ROUND_CEILING:如果Decimal为正,则做ROUND_UP操作;如果Decimal为负,则做ROUND_DOWN操作;

4)ROUND_FLOOR:如果Decimal为负,则做ROUND_UP操作;如果Decimal为正,则做ROUND_DOWN操作;

5)ROUND_HALF_DOWN:如果舍弃部分>.5,则做ROUND_UP操作;否则,做ROUND_DOWN操作;

6)ROUND_HALF_UP:如果舍弃部分>=.5,则做ROUND_UP操作;否则,做ROUND_DOWN操作,就是一般定义下的四舍五入;

7)ROUND_HALF_EVEN:如果舍弃部分左边的数字是奇数,则做ROUND_HALF_UP操作;若为偶数,则做ROUND_HALF_DOWN操作,就是默认的round行为,所谓的银行家算法;

我们可以将round行为全部调成ROUND_HALF_UP与一般人认知保持一致。

一些值得一说的性质:

  1. Decimal 不能直接和 float 进行计算,需要手动把 float 格式的数转化成 Decimal 才可计算。

  2. Decimal 不是完全不会损失精度的,它只能保证在设定的精度范围内不会丢失精度,超出部分会做round处理,所以我们使用时应该设置好我们需要的最小精度范围。

  3. 默认设置下 Decimal 接受 float 值的传参,然后根据精度设置做round处理,如果不愿意利用这个特性,可以通过设置 traps 在传入 float 时抛出异常,这样有助于保证数据的绝对正确。

  4. 在不同的 context 中,相同的计算公式得出的结果很可能是不等的,因为根据不同的精度设置有可能得到不同的结果,所以非必要情况下,不要切换 context

  5. 在精度有限的条件下,计算的先后顺序也可能导致结果的不同:

   >>> getcontext().prec = 8
   >>> u, v, w = Decimal(11111113), Decimal(-11111111), Decimal('7.51111111')
   >>> (u + v) + w
   Decimal('9.5111111')
   >>> u + (v + w)
   Decimal('10')

   >>> u, v, w = Decimal(20000), Decimal(-6), Decimal('6.0000003')
   >>> (u*v) + (u*w)
   Decimal('0.01')
   >>> u * (v+w)
   Decimal('0.0060000')
  1. 特殊的 Decimal 值计算的结果

Decimal 中 有  NaNsNaN-InfinityInfinity,, +0 和 -0.这几个不同寻常的值。

Infinity在不抛出DivisionByZero的情况下可以通过非0的值处以0得到

NaN在不抛出InvalidOperation的情况下可以通过0/0或者Infinity/Infinity得到

NaN和包括自己的任何值做除了不等以外的任何比较,结果都是False。

A variant is sNaN which signals rather than remaining quiet after every operation. This is a useful return value when an invalid result needs to interrupt a calculation for special handling. 这个我没看懂。

如果不愿意在代码里抛出相关异常,这些值在计算中就很有用了。

  1. 可以利用import decimal.Decimal as D来简化代码

  2. 利用 signals 里的 Inexact可以有效检查计算过程中生成的不精确值。

  3. quantize方法用于保持计算过程中的有效数字保留,可以在这个方法内指定Inexact来做校验,避免精度丢失

```

a = Decimal('102.72') b = Decimal('3.17') (a * b).quantize(TWOPLACES) # Must quantize non-integer multiplication Decimal('325.62') (b / a).quantize(TWOPLACES) # And quantize division Decimal('0.03')

Decimal('3.214').quantize(TWOPLACES, context=Context(traps=[Inexact])) Traceback (most recent call last): ... Inexact: None ```

  1. 在初始化时,Decimal 是不会根据 context 中设置的精度丢失精度的,除非超出了硬件限制,抛出异常,可以利用一元操作符强制做 round 处理

    ```

    getcontext().prec = 3 Decimal('1.23456789') Decimal('1.23456789')

    +Decimal('1.23456789') # unary plus triggers rounding Decimal('1.23') ```

  2. getcontext()方法访问每个线程不同的 context 对象,setcontext()有相同的行为模式,它只会影响当前线程的context值。如果getcontextsetcontext前调用,会从 DefaultContext复制一份出来,所以如果要保持全局设置一致,直接改DefaultContext就好。